정수를 void 포인터 인자에 실어 보내기 — intptr_t 캐스팅 관용구
NOTE
pthread_create처럼 인자를 단 하나(void *)만 받는 스레드 함수나 콜백에 워커 ID, 인덱스, 파일 디스크립터 같은 정수 하나를 전달할 때,malloc없이 포인터 변수 자체를 8바이트 값 그릇으로 활용하는 저수준 캐스팅 관용구((void *)(long)val)와 그 동작 원리를 정리한다.
0. 핵심 의문: “주소 0x00000003을 넣었는데 왜 세그폴트가 안 나는가?”
초보자나 고수준 언어 개발자가 이 패턴을 처음 볼 때 가장 많이 하는 질문이다:
“인덱스가 3일 때 포인터에 0x00000003을 넣는다면, 실제 메모리 3번지(
0x3)를 가리키는 것 아닌가요? 3번지에 다른 쓰레기값이 있거나 접근 금지 영역이라 크래시(세그폴트)가 나야 정상 아닌가요?”
(1) 포인터는 단지 ‘64비트(8바이트) 크기의 메모리 칸’일 뿐이다
컴퓨터 아키텍처(CPU 레지스터와 RAM) 관점에서 볼 때, 포인터 변수 void *arg는 어떤 특별한 마법이 걸린 타입이 아니다. 단지 **64비트(8바이트) 크기의 정수를 담을 수 있는 메모리 칸(또는 RDI 레지스터)**에 불과하다.
void *arg = (void *)(long)3;을 실행하면, 그 변수 칸에는 2진수 비트 0x0000000000000003이 그대로 채워진다. 값을 넣는 행위 자체로는 CPU가 메모리 3번지를 방문하지 않는다.
(2) ‘쪽지에 적힌 집 주소’ vs ‘쪽지에 적힌 숫자 암호’ 비유
포인터의 위험 여부는 오직 **“역참조(Dereference, * 연산자)“**를 하느냐 마느냐에 달려 있다.
[일반적인 포인터 사용] — 주소 방문 (역참조 O)
메인 스레드 ──(쪽지에 '역삼동 100번지' 적어 전달)──> 워커 스레드
│
▼ (*ptr 실행)
실제 '역삼동 100번지'로 찾아가서
대문을 열고 물건을 꺼냄!
[정수를 실어 보낸 트릭] — 숫자만 읽음 (역참조 X)
메인 스레드 ──(쪽지에 암호 숫자 '3' 적어 전달)──────> 워커 스레드
│
▼ ((long)ptr 실행)
쪽지에 적힌 숫자 '3'을 그대로 읽고 끝!
어떤 집(메모리 번지)도 방문하지 않음!*(long *)arg(역참조 시도): “0x00000003 번지 메모리의 문을 열고 8바이트를 읽어와라!” ➡️ 리눅스 가상 메모리에서0x0 ~ 0xFFF영역은 NULL 포인터 방어를 위해 접근이 엄격히 금지된 보호 구역(Trap Page)이므로, CPU 페이지 폴트가 발생하며 즉시Segmentation Fault (SIGSEGV)로 프로세스가 강제 종료된다.(long)arg(정수 변환): “내 손에 쥐고 있는 변수 칸의 64비트 비트열0x3을 그대로 정수 3으로 읽어라!” ➡️ 메모리 0x3 번지 근처에는 발도 들이지 않으므로 100% 안전하다.
1. 재앙의 시나리오: 스레드 생성 루프에서 &for_i(주소)를 넘기면 왜 전멸하는가?
스레드를 여러 개 띄우면서 각 워커의 고유 번호(0, 1, 2, …)를 넘길 때 흔히 저지르는 **치명적인 동시성 버그(Race Condition)**가 바로 지역 변수의 주소를 넘기는 것이다.
/* ⚠️ 치명적 버그 코드 — 절대로 이렇게 작성하면 안 됨 */
for (int for_i = 0; for_i < 4; for_i++) {
pthread_create(&tid[for_i], NULL, worker_thr, &for_i);
}실행 타임라인 분석
시간(Time) 메인 스레드 (루프 실행) 워커 스레드 0, 1, 2, 3 (OS 스케줄링)
│
├─ 1. for_i = 0 대입
├─ 2. pthread_create(..., &for_i) 호출 ─────┐
├─ 3. 루프 바로 진행! for_i = 1 대입 │
├─ 4. pthread_create(..., &for_i) 호출 ──┐ │
├─ 5. 루프 바로 진행! for_i = 2 대입 │ │
├─ 6. 루프 계속 진행... for_i = 4 (종료) │ │
│ │ │ (OS가 뒤늦게 스레드들을 깨움)
▼ │ │
▼ │
[워커 0 깨어남]: int my_id = *(int *)arg;
👉 &for_i 메모리를 열어보니 이미 4가 적혀 있음!
│
▼
[워커 1 깨어남]: int my_id = *(int *)arg;
👉 &for_i 메모리를 열어보니 역시 4가 적혀 있음!- Race Condition:
pthread_create는 스레드 생성을 요청하고 즉시 리턴한다. 메인 스레드는 CPU 수백 클럭 만에 루프를 다 돌아서for_i가 최종값(예: 4)에 도달해 버린다. - 동일 인덱스 중복: 뒤늦게 깨어난 모든 워커 스레드가 동일한 메모리 주소(
&for_i)를 읽으므로, 모든 워커가my_id = 4를 바라보게 된다. (워커 0, 1, 2, 3이 각자 전용 큐를 가지지 못하고 엉뚱한 큐 하나에 몰리거나 범위를 초과함). - Dangling Pointer (스택 소멸): 만약 루프가 끝난 뒤 해당 함수가 리턴해버리면,
for_i가 있던 스택 프레임 자체가 소멸한다. 워커 스레드가 뒤늦게*(int *)arg를 실행하면 이미 다른 함수가 덮어쓴 쓰레기 데이터를 읽어 대참사가 난다.
2. 해결책: 포인터 그릇에 값 자체를 실어 보내기 (Pass by Value)
주소(&for_i)가 아니라, **그 순간의 정수 값 자체(for_i)**를 64비트 크기로 캐스팅하여 포인터 자리에 넣어서 넘긴다.
for (int for_i = 0; for_i < n_max_thread; for_i++) {
/* 값(0, 1, 2, 3) 그 자체를 64비트 포인터 크기로 캐스팅해서 전달 */
pst_workthr[for_i].mpthr = mpthr_init(
sz_name, 1, mnfmng_work_thr,
(void *)(long)for_i, /* 또는 (void *)(intptr_t)for_i */
grace_time
);
}- 함수 호출 시점에
for_i의 값(예: 0, 1, 2…)이 CPU 레지스터에 복사(Pass by Value)되어 스레드 인자 저장소로 들어간다. - 메인 스레드가 루프를 계속 돌아서
for_i가 4로 변하든, 함수가 리턴해서 스택이 날아가든, 각 워커 스레드는 생성 당시에 복사된 자기만의 고유한 정수 값을 완벽하게 보존한다.
3. 왜 (void *)for_i로 바로 캐스팅하면 안 되는가?
int for_i = 3;
void *pv_arg = (void *)for_i; // ⚠️ 컴파일러 경고 발생!GCC/Clang 경고 메시지
warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]- 64비트 시스템(x86_64, AArch64)에서:
int는 4바이트 (32비트)void *는 8바이트 (64비트)
- 4바이트 정수를 8바이트 포인터로 한 번에 바꾸려고 하면, 컴파일러는 “상위 4바이트에 부호 확장을 해야 하는지, 0으로 채워야 하는지 모호하고 크기가 맞지 않는다”고 판단하여 경고를 띄운다.
해결: 2단계 캐스팅 (32비트 정수 ➡️ 64비트 정수 ➡️ 64비트 포인터)
int for_i (32비트)
└── (long) 또는 (intptr_t) ──> 64비트 정수로 크기 확장
└── (void *) ──────────> 64비트 포인터로 타입 변환 (크기가 8바이트로 동일하므로 경고 없음)4. (void *)(long) vs (void *)(intptr_t) — 실무 코드와 C 표준
실무 C 코드베이스를 보면 (long)을 거치는 코드와 (intptr_t)를 거치는 코드가 모두 존재한다. 둘의 차이는 플랫폼 모델과 C 표준 역사에 있다.
| 구분 | (void *)(long)val | (void *)(intptr_t)val |
|---|---|---|
| 등장 배경 | 전통적인 유닉스/리눅스 C 관용구 | C99 표준 (<stdint.h>) 공식 도입 |
| Linux / Unix (64-bit) | LP64 모델: long = 8바이트, pointer = 8바이트 ➡️ 완벽하게 안전 | intptr_t = 8바이트 ➡️ 완벽하게 안전 |
| Windows (64-bit) | LLP64 모델: long = 4바이트, pointer = 8바이트 ➡️ ⚠️ 경고 발생! | intptr_t = 8바이트 ➡️ 완벽하게 안전 |
| 권장 사항 | Linux 전용 고성능 통신 데몬 등 실무 코드에서 여전히 널리 사용됨 | OS/플랫폼 독립적인 모던 C 표준 권장 방식 |
- 실무 리눅스 시스템 프로그래밍: 리눅스 x86_64 환경에서는
long이 항상 포인터와 동일한 64비트이므로(void *)(long)캐스팅이 오랫동안 관용구로 정착되어 널리 쓰인다. - 신규 작성 코드: 플랫폼 이식성을 보장해야 한다면
<stdint.h>를 include하고intptr_t(부호 있음) 또는uintptr_t(부호 없음)를 사용하는 것이 가장 이상적이다.
5. 워커 스레드 내부에서의 수신 (역참조 금지!)
워커 스레드 함수 본체에서는 보낼 때의 역순으로 포인터를 정수로 복원한다.
void *mnfmng_work_thr(void *pv_arg)
{
/* ✅ 올바른 수신: 포인터 그릇에 든 8바이트 값을 그대로 long 정수로 복원 */
long n_work_idx = (long)pv_arg;
/* 또는 intptr_t를 사용한 경우 */
/* int n_work_idx = (int)(intptr_t)pv_arg; */
MPGLOG_INFO("워커 스레드 시작: worker_id=%ld\n", n_work_idx);
while (g_b_run) {
/* n_work_idx(0, 1, ...)를 사용해 자기 전용 큐에서 패킷 꺼내기 */
process_queue(n_work_idx);
}
return NULL;
}절대 하지 말아야 할 코드 (사망 플래그)
/* 💥 CRASH! SIGSEGV 발생 */
long n_work_idx = *(long *)pv_arg; (long *)pv_arg:0x00000003을 주소로 취급.*:0x00000003번지의 메모리를 읽으려 시도 ➡️ OS 세그폴트 시그널로 즉사.
6. 정수 전달 방식 3가지 비교 요약
| 방식 | 코드 예시 | 메모리 할당 | 동기화 버그 위험 | 해제(free) 책임 |
|---|---|---|---|---|
1. 주소 전달 (&i) | pthread_create(..., &for_i); | 없음 | 🚨 매우 위험 (Race Condition, 스택 소멸) | 없음 |
2. 동적 할당 (malloc) | int *p = malloc(sizeof(int));*p = for_i;pthread_create(..., p); | 힙 할당 (오버헤드 발생) | 안전함 | ⚠️ 필수 (워커가 끝나며 free(p) 해야 함, 누수 위험) |
| 3. 포인터 그릇 적재 (본 문서의 패턴) | pthread_create(..., (void *)(long)for_i); | 0바이트 (완전 무료) | 완벽하게 안전 (값 복사 방식) | 불필요 (할당한 적이 없으므로 해제도 없음) |
7. 자주 하는 실수 & 체크리스트
*(int *)arg역참조 절대 금지- 포인터에 값을 실어 보냈다면, 받는 쪽에서도 반드시 역참조 기호(
*) 없이 캐스팅만으로 꺼내야 한다.
- 포인터에 값을 실어 보냈다면, 받는 쪽에서도 반드시 역참조 기호(
- 구조체나 둘 이상의 값은 이 트릭 불가
- 64비트(8바이트) 크기 그릇 하나이므로,
int나long,enum, 파일 디스크립터(fd)처럼 8바이트 이하의 단일 값에만 쓸 수 있다. - 전달할 값이 여러 개라면 정직하게 구조체를 정의하고
malloc하거나 전역 구조체 배열의 포인터를 넘겨야 한다.
- 64비트(8바이트) 크기 그릇 하나이므로,
- 부호(Sign) 확장 주의
- 음수가 될 수 있는 값(
int,ssize_t)은intptr_t/long을 사용하고, 크기나 인덱스(size_t,uint32_t)처럼 양수만 쓰는 값은uintptr_t/unsigned long을 사용하여 비트 왜곡을 방지한다.
- 음수가 될 수 있는 값(